iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 13

Day 13:Session 管理與生命週期

  • 分享至 

  • xImage
  •  

Day 13:Session 管理與生命週期

ERP 系統的每一次請求都要先回答兩件事:誰在呼叫、他在哪一家公司。框架若不統一管理,這兩個問題會長進每一段業務邏輯的開頭,變成到處複製的樣板程式碼。

框架的作法是把答案集中在一個物件上:SessionInfo。它看起來只記著「誰登入了」,實際上資料庫路由、客製化、語系、時區、加密、權限這幾條線全部在它身上取值。

本篇說明:

  1. Business Object 取得執行脈絡的方式
  2. Session 的兩階段生命週期
  3. Session 的持久化與重建
  4. CultureTimeZone 預設值的設計取捨

一、Business Object 的執行脈絡

寫第一個 BO 方法時就會碰到的問題:它要先知道「現在是誰」,而取得的方式有三種。

作法 問題
靜態的「目前使用者」 同時處理多個請求的伺服器上必定互相污染
每個方法簽章多掛參數 每一層都要轉手用不到的東西,漏傳不會有人告訴你
建構時交到手上 框架採用這一種

BO 的建構子固定四個參數,宣告在 BusinessObject.cs

protected BusinessObject(IBeeContext ctx, Guid accessToken, string progId, bool isLocalCall = true)
  • ctx:一整包已解析的服務(見下)
  • accessToken:這次呼叫的 session 識別
  • progId:呼叫對象的程式識別碼
  • isLocalCall:是否為同行程呼叫

所有 BO 共用同一個簽章,是為了讓工廠不必先知道要建的是哪一個家族(註冊表本身是 Day 15 的題目)。

IBeeContext 裝了什麼

這一包的內容全宣告在 IBeeContext 上:

public interface IBeeContext
{
    IDefineAccess DefineAccess { get; }               // 定義存取
    ISessionInfoService SessionInfoService { get; }   // Session 存取
    ILanguageService LanguageService { get; }         // 語系文字
    IBusinessObjectFactory BoFactory { get; }         // BO 之間互相呼叫
    IServiceProvider Services { get; }                // 逃生門
}

前四個具名,因為幾乎每個 BO 方法都會用到。第五個是逃生門,只給少數方法用,例如登入才需要的那幾個輔助服務。它刻意留成同一個固定寫法,所以誰用了它、用在哪裡,全域搜尋一次就盤得出來。開一個有記號的逃生門,比讓人另外挖一個沒記號的洞好。

這一包裡刻意沒有的東西:

  • 沒有 DbConnection
  • 沒有 DbTransaction
  • 沒有連線字串

BO 要碰資料一律走 Repository,由 Repository 自己宣告的資料庫分類,加上 session 解析出的公司,決定連哪一個資料庫(Day 11 已展開)。

還有一個問題,答案同樣不在這一包裡:這一段做完算不算成功。transaction 不是 BO 開的。存檔時由 Repository 開一個 transaction 包住整筆單據,master 先、detail 後,全成功才 commit。BO 覆寫進去的那幾小段跑在 transaction 的哪一邊,是明天整篇的題目。

四個參數裡最不起眼的是 accessToken,它只是一個 Guid。而 BO 需要知道的每一件事,最後都是拿它換來的。


二、Session 的兩階段生命週期

多公司部署時的實際問題:把 companyId 併進 Login 會碰到三個結構性障礙。

  • 順序是反的:使用者能進哪些公司,要驗證身分之後才查得出來
  • 切換成本高:切公司等於登出再登入,token 重發、加密金鑰重建,畫面上未存的資料全消失
  • 中間狀態無處表達:「已登入、尚未進公司」沒有欄位可以放

框架因此把 session 切成兩段,四個對稱方法:

Login(account, password)   ←→  Logout()
EnterCompany(companyId)    ←→  LeaveCompany()

兩對是巢狀的,外層管身分,內層管公司:

方法 做完之後
Login 有身分。能呼叫不綁公司的方法,EnterCompany 就是其中一支
EnterCompany 有公司脈絡。company 那一類要連哪一個資料庫,這時才解得出來
LeaveCompany 公司脈絡清掉,session 還活著
Logout session 銷毀,內部會先清一次公司脈絡

表上看不出來的兩件事:

  • 切換公司不必先 LeaveCompanyEnterCompany 直接覆寫舊值(兩步驟會產生非原子的中間狀態)
  • 公司不存在、公司停用、無存取權三種失敗合併為同一個錯誤,避免被拿來列舉系統裡有哪些公司

登入失敗處理

Login 是匿名的,任何人都打得到,所以它另外掛著一個計數器。ILoginAttemptTracker 記同一個帳號連續失敗幾次,登入成功就歸零;框架附的預設實作在連續失敗到一定次數之後把那個帳號鎖上一段時間,而宿主可以整支換掉。

鎖多久、鎖帳號還是鎖來源、要不要通知,是各家資安政策決定的:有的要配合公司既有的帳號規定,有的要接通知管道,有的根本不鎖只記錄。而「連續失敗要被數到」各家都一樣。Day 2 那條判別法在認證上就長這個樣子。

不一樣的那一半,框架現在開的是一個可替換的實作,不是一份設定。ERP 的資安管理最後多半會落在設定層,一家公司一組門檻;要走到那裡,得先有夠多部署把同一組參數提出來。

SessionInfo 裝了什麼

兩階段不只是流程規約,它就是 SessionInfo 這個型別的形狀:公開屬性依「由哪一段填入」分成兩組。

Login 填入(七個)

屬性 內容 消費者
AccessToken session 識別(Guid 每次身分驗證
ExpiredAt 到期時間(UTC) 每次身分驗證
UserId 帳號 稽核(Day 25)
UserName 顯示名稱 稽核(Day 25)
Culture BCP-47 語言代碼 多語系(Day 20)
TimeZone IANA 時區代碼 時區換算(Day 28)
ApiEncryptionKey 本連線的對稱金鑰 Payload 加密(Day 23)

EnterCompany 填入(六個)

屬性 內容 消費者
CompanyId 目前公司 資料庫路由(Day 11)
CustomizeId 該公司套用的客製化代碼 客製化(Day 19)
Roles 使用者在該公司的角色 權限(Day 24)
UserRowId st_user.sys_rowid 權限(Day 24)
EmployeeRowId st_employee.sys_rowid 權限(Day 24)
DeptRowId st_employee.dept_rowid 權限(Day 24)

CompanyId 是其中唯一可為 null 的,語意明確:已登入、尚未進公司。Day 11 講過 Repository 在建構當下就解析路由,而這個狀態正是解不出來的情況之一,會被當場擋掉。

公司代號還會換到一份 CompanyInfo,這一份在快取裡,來源是一句查詢,第一次用到才讀。它除了回答公司資料庫位址,還帶著公司層的覆寫:數字位數、公司本幣、現金最小收付單位、可用幣別清單。進公司備好的不只是一個位址,數值語意本身是 Day 26 的題目。

六個屬性同進同出

這六個值由同一支私有方法一次清掉,它與本篇後面幾個片段都出自 SystemBusinessObject.Session.cs

private static void ClearCompanyContext(SessionInfo sessionInfo)
{
    sessionInfo.CompanyId = null;
    sessionInfo.CustomizeId = string.Empty;
    sessionInfo.Roles = [];
    sessionInfo.UserRowId = Guid.Empty;
    sessionInfo.EmployeeRowId = Guid.Empty;
    sessionInfo.DeptRowId = Guid.Empty;
}

LeaveCompanyLogout 走的都是這一支。


三、Session 的持久化與重建

伺服器重啟或負載平衡把下一個請求送到另一台機器,手上的 token 還算不算數?

  • 只放記憶體:不算數,每次發版都要把所有人踢下線
  • 整份寫進資料庫:登入當下快照的權限,會在使用者被調離公司之後繼續生效

框架兩份都留,但寫進 st_session 的只有 session 自己的身分與到期時間,加上重建時湊不回來的那一個值。這一份稱為 seed,型別是 SessionUser

new SessionUser
{
    AccessToken = sessionInfo.AccessToken,
    UserID = sessionInfo.UserId,
    UserName = sessionInfo.UserName,
    EndTime = sessionInfo.ExpiredAt,
    CompanyId = sessionInfo.CompanyId,
}

seed 只放五個值,其餘的都能重新產生:

重建方式
Culture / TimeZone 重讀 st_user
ApiEncryptionKey 由 token 重新推導
CustomizeId / Roles / 三個 RowId 重跑 EnterCompany 的解析

只有「使用者選了哪一家公司」在資料庫裡沒有其他地方記得,所以必須存。

快取沒命中時的重建有三個性質:

  • 重跑推導,不是還原快照。登入後才被撤銷的公司權限不會存活,該 session 直接回不來。
  • EnterCompany 與重建共用同一段程式(SessionCompanyBinder)。兩條路必須落在相同狀態,否則權限會取決於請求打到哪一台機器。
  • seed 先寫、快取後放。兩種中間狀態不等價:有 seed 沒快取,下個請求重建即可;有快取沒 seed,這個 token 下次重啟就死,且在別台機器上根本不存在,client 無從察覺。

另一支發得出 token 的方法

CreateSession 拿一個帳號直接發一張 token,完全不驗憑證,與 Login 的差別只有這一件;後面推導金鑰、寫 seed、放快取那一整段共用同一個方法。

用途是排程、匯入、對帳這類沒有人坐在前面打密碼、卻仍然需要一個身分才跑得動的作業。沒有它,那些作業只剩兩條路:在設定檔裡放一組服務帳號的密碼,或乾脆繞過整套權限自己開連線。

代價是這支方法本身就是一把萬能鑰匙,所以它在存取控制上宣告成只接受同行程呼叫:走 HTTP 進來的請求在進到方法之前就被擋掉,能呼叫它的只有跟後端跑在同一個行程裡的程式。第一節建構子那個 isLocalCall 收的是同一個值,不經過那道驗證的呼叫路徑,就靠它擋。


四、CultureTimeZone 的預設值

這兩個屬性的預設值都是空字串,而且是刻意的。

  • 語言服務遇到空值 → 退回系統預設語言
  • FrameworkClock / DateTimeZoneConverter / PayloadZoneConverter 遇到空時區 → 一律當 UTC

空字串的語意是「走預設路徑」,不是「還沒填」。

真正填值的是登入那一段(repo 是使用者資料表的 Repository):

var locale = repo.GetLocale(sessionInfo.UserId);
var backend = DefineAccess.GetSystemSettings().BackendConfiguration;
sessionInfo.TimeZone = StringUtilities.IsNotEmpty(locale.TimeZone)
    ? locale.TimeZone : backend.DefaultTimeZone;
sessionInfo.Culture = StringUtilities.IsNotEmpty(locale.Culture)
    ? locale.Culture : backend.DefaultLanguage;

這兩個值往回退有三層:st_user 那一列 → BackendConfiguration → 各消費端自己的預設。

BackendConfiguration 這兩個設定的預設值是 zh-TWAsia/Taipei,所以什麼都沒調的部署拿到的就是它們。這個決定放在設定裡,不在原始碼裡:填空字串即可讓整個部署走各消費端自己的預設。未登入的呼叫沒有 session,走的一直是最後那一層。


回到 Northwind

案例只有一家公司,登入之後照樣走完進公司那一步。

  • 兩段都零應用程式碼:登入走框架自己的 st_user(雜湊比對、顯示名稱同列讀出),進公司走框架自己的 EnterCompany。應用只 seed 兩列資料,寫在 NorthwindSchemaSeeder.csst_company 一列,客製化代碼就填在它的 customize_idst_user_company 一列,少了它 EnterCompany 一律當成無存取權
  • 前端登入成功之後就接著進公司,中間沒有公司清單,因為只有一家。有好幾家的時候,那份清單就插在這兩步中間
  • 看得出來的兩件小事:示範帳號在 st_user 填了 Asia/Taipeien-US,兩欄都走「使用者自有值」那條路;語言那一欄與部署預設的 zh-TW 不同,所以分得出來走的是哪一條;CompanyId 一路走到路由,把所有歸在 company 那一類的表單接到同一個資料庫

一家公司也要走兩段,看起來是形式。真正省不掉的是進公司那一步寫進 seed 的公司代號,session 上其他的值都能重算,只有這一個不行。


小結

一個 session 要管的值,分兩段到位:

  • Login 答完「你是誰」,同時備好語言、時區與加密金鑰
  • EnterCompany 答完「在哪一家公司」,同時把跟著公司走的那幾個一起填上

這條界線不是規約,是相依關係算出來的:第二組的每一個值都得先知道公司才推導得出來。先把值與值之間的相依排清楚,生命週期通常就已經被決定了。

可以帶走的判準:框架要求應用「只寫業務判斷」的時候,它欠應用一份清單,寫明哪些問題已經被答完。清單缺一項,那一項就會以某種形式長回每一段業務邏輯的開頭:一個靜態的目前使用者、一個到處轉手的參數、或一個每次都要重查的查詢。

明天談框架把存檔切成哪幾個擴充點,以及什麼時候該接手其中一個。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 12:定義與資料快取的跨節點失效
下一篇
Day 14:業務物件的擴充點與覆寫時機
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言